iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0

前言

第一週的收尾終於來了!一路上我們從一個空蕩蕩的專案開始,一路加上了 API 串接、動態題庫、程式碼編輯器,最後到學習基本的 Prompt Engineering,專案本身已經初見雛形了,雖然它還稱不上一個面試官,比較像是陪你練習面試的考官就是了。

我們今天的目標就是把這一週的一切全部整合起來並將整個專案部署,讓你有個可分享的 MVP,稍稍有點成就感會比較容易堅持下去,那我們馬上開始吧!

今日目標

  • 調整專案結構。
  • 將 UI 拆分成多個可複用的子元件。
  • 實踐我們在Day 3的設計,實作主控台、練習與紀錄等頁面。
  • 將專案部署到 Vercel 上。

再次強調一下,UI 與前端畫面實作的細節並不是我這系列文想強調的重點,這部分我也是透過提示詞請 AI 給我初稿後我再自己調整後的成品,畢竟我想示範的是概念,而不是真的要一步步教你每個元件怎麼寫、怎麼去善用Next.js每一個核心功能之類的細項,因此在實作頁面 UI 跟組件的部分我會快速帶過,我甚至不會去解釋怎麼佈局之類的點,還麻煩你們見諒啦!如果你對這部分真的沒興趣,可以直接去我文章尾巴提供的遠端連結複製整個專案,然後再照著最後一部分部署流程走就好了!

Step 1: 建立全新的專案結構

為了容納我們複雜的多頁面應用,我們需要一個更有組織的資料夾結構。請依照以下結構建立新的資料夾與檔案:

/app  
├── (main)/              \# 主應用程式的路由群組  
│   ├── dashboard/       \# 主控台頁面  
│   │   └── page.tsx  
│   ├── practice/  
│   │   ├── [type]/      \# 主題選擇頁 (concept | code)  
│   │   │   └── page.tsx  
│   │   └── interview/  
│   │       ├── [sessionId]/ \# 面試頁面  
│   │       │   └── page.tsx  
│   ├── history/  
│   │   ├── page.tsx     \# 練習紀錄列表  
│   │   └── [recordId]/  \# 紀錄詳情  
│   │       └── page.tsx  
│   └── layout.tsx         \# 主應用程式的佈局  
├── api/  
│   └── ... (之前的 API 路由不變)  
├── components/          \# 共用元件庫  
│   ├── layout/  
│   │   └── Sidebar.tsx  
│   └── interview/  
│       ├── AiMessage.tsx  
│       └── UserMessage.tsx 
│       └── CodingInterview.tsx 
│       └── ConceptualInterview.tsx 
├── lib/                   \# 放置共用邏輯或模擬資料  
│   └── mockData.ts  
├── types/  
│   └── ... (之前的型別定義不變)
│   └── interview.ts (新增interview.ts檔案)
└── layout.tsx
  • 路由群組 (main): 這是 Next.js App Router 的一個技巧,可以讓我們為一組頁面共用一個 layout.tsx,而不會影響 URL 路徑。
  • components/: 存放所有可複用的 React 元件。
  • 資料夾用[]命名也是Next的技巧,在括號內的內容會作為params傳入頁面中使用,例如,在組件中可以利用params做一些判斷,以我們的情況來說就可以用來判斷是哪個主題或是哪個sessionId。

Step 2: 打造共用元件與主佈局

首先,我們來建立整個應用程式的骨架,為了快速示範,我們會需要先安裝一個 icon 套件方便我們做展示,在開始前請你先在終端機輸入以下的指令:

npm i lucide-react

lucide-react 是一個開源、輕量級的 React 圖示庫。它的核心理念是簡潔、一致與高可讀性,在業界算是一個常用的選擇之一,甚至許多 AI 模型在回覆時也會經常使用它做頁面的示範。
完成安裝之後就可以把目光放到components/layout/Sidebar.tsx這隻檔案了,這是我們左側的導航欄,會在所有的頁面使用。

'use client';
import {
  BotMessageSquare,
  LayoutDashboard,
  MessageSquare,
  Code,
  History,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

const navItems = [
  { href: '/dashboard', label: '主控台', icon: LayoutDashboard },
  { href: '/practice/concept', label: '概念問答', icon: MessageSquare },
  { href: '/practice/code', label: '程式實作', icon: Code },
  { href: '/history', label: '練習紀錄', icon: History },
  // { href: '/settings', label: '設定', icon: Settings }, // 設定頁面尚未實作
];

export default function Sidebar() {
  const pathname = usePathname();

  const isActive = (href: string) => {
    if (href === '/dashboard') return pathname === href;
    return pathname.startsWith(href);
  };

  return (
    <nav className="hidden md:flex w-64 bg-[#111827] text-gray-300 flex-col p-4 border-r border-gray-700">
      <div className="flex items-center gap-3 mb-8">
        <BotMessageSquare size={32} className="text-blue-400" />
        <h1 className="text-xl font-bold">AI Interview Pro</h1>
      </div>
      <ul className="space-y-2">
        {navItems.map(({ href, label, icon: Icon }) => (
          <li key={label}>
            <Link
              href={href}
              className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
                isActive(href)
                  ? 'bg-blue-600/30 text-white'
                  : 'hover:bg-gray-700'
              }`}
            >
              <Icon size={20} /> {label}
            </Link>
          </li>
        ))}
      </ul>
    </nav>
  );
}

接著回到我們的 main 資料夾下,將剛剛寫好的 Sidebar 和我們的主要內容區域app/(main)/layout.tsx組合起來。

import Sidebar from '@/app/components/layout/Sidebar';

export default function MainLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <main className="min-h-screen bg-[#030712] text-white flex font-sans">
      <Sidebar />
      <div className="flex-1 flex flex-col overflow-hidden">
        <div className="flex-1 overflow-y-auto">{children}</div>
      </div>
    </main>
  );
}

Step 3: 實作主控台 (Dashboard)

現在來實作我們的首頁,首頁的部分我們會需要展示一些使用者目前的練習進度之類的資訊,因此會需要一些假資料在這個階段協助我們做呈現。

請你在寫入以下的內容

const mockDashboardStats = {
  totalSessions: 12,
  averageScore: 85,
  streak: 3,
};

const mockTopicProgress = [
  { topic: 'JavaScript (ES6+)', progress: 75, color: 'text-yellow-400' },
  { topic: 'React', progress: 85, color: 'text-blue-400' },
  { topic: 'TypeScript', progress: 60, color: 'text-cyan-400' },
  { topic: 'CSS & 版面設計', progress: 70, color: 'text-pink-400' },
  { topic: 'Web 效能', progress: 45, color: 'text-green-400' },
];

const mockConceptualTopics = [
  {
    id: 'js-core',
    title: 'JavaScript 核心觀念',
    description: '深入探討 Hoisting, Closure, Prototype 等基礎。',
    progress: 75,
    color: 'text-yellow-400',
    bgColor: 'bg-yellow-900/20',
    ringColor: 'ring-yellow-500',
  },
  {
    id: 'react-core',
    title: 'React 基礎與 Hooks',
    description: '從元件生命週期到 state 與 effect 的掌握。',
    progress: 85,
    color: 'text-blue-400',
    bgColor: 'bg-blue-900/20',
    ringColor: 'ring-blue-500',
  },
  {
    id: 'ts-basics',
    title: 'TypeScript 基礎',
    description: '理解型別、泛型與 Interface 的應用。',
    progress: 60,
    color: 'text-cyan-400',
    bgColor: 'bg-cyan-900/20',
    ringColor: 'ring-cyan-500',
  },
  {
    id: 'network',
    title: '網路請求與非同步',
    description: '關於 Fetch, Promise 與 async/await 的一切。',
    progress: 55,
    color: 'text-indigo-400',
    bgColor: 'bg-indigo-900/20',
    ringColor: 'ring-indigo-500',
  },
];

const mockCodingTopics = [
  {
    id: 'css-layout',
    title: 'CSS 版面挑戰',
    description: '使用 Flexbox 與 Grid 打造複雜響應式版面。',
    progress: 70,
    color: 'text-pink-400',
    bgColor: 'bg-pink-900/20',
    ringColor: 'ring-pink-500',
  },
  {
    id: 'react-hooks-impl',
    title: '實作 React Hooks',
    description: '從零開始打造 useDebounce, useToggle 等工具。',
    progress: 85,
    color: 'text-blue-400',
    bgColor: 'bg-blue-900/20',
    ringColor: 'ring-blue-500',
  },
  {
    id: 'algo-easy',
    title: '演算法入門',
    description: '常見的字串與陣列操作題目。',
    progress: 65,
    color: 'text-green-400',
    bgColor: 'bg-green-900/20',
    ringColor: 'ring-green-500',
  },
];

const mockInterviewSession = {
  title: 'React Hooks 深度剖析',
  questionNumber: 3,
  currentQuestion: {
    type: '程式題',
    title: '實作一個自定義 Hook: `useDebounce`',
    description:
      '請你實作一個名為 `useDebounce` 的自定義 React Hook。這個 Hook 接收兩個參數:一個是需要被防抖的值(value),另一個是延遲時間(delay)。它應該在 value 停止變化 delay 毫秒後,才回傳最新的 value。',
    templateCode: `import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
  // 請在這裡實作你的 debounce 邏輯
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// --- 範例使用 ---
export default function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  // ... 接下來的 UI 會使用 debouncedSearchTerm ...
}`,
  },
};

const mockChatHistory = [
  {
    role: 'ai',
    content:
      '你好!我是你的 AI 前端面試官。準備好後,我們就開始今天的 React Hooks 主題面試。',
  },
  { role: 'user', content: '我準備好了,請出題吧!' },
  {
    role: 'ai',
    content:
      '好的,第一題:**請解釋在 React 中,`useEffect` 的第二個參數(依賴陣列)有哪些常見的用法和情境?**',
  },
  {
    role: 'user',
    content:
      '`useEffect` 的依賴陣列主要有三種情境:1. 不傳遞:每次 re-render 都會執行。 2. 傳遞空陣列 `[]`:只在元件首次掛載時執行,類似 `componentDidMount`。 3. 傳遞包含變數的陣列 `[dep1, dep2]`:當陣列中的任何一個變數發生變化時,effect 就會重新執行。',
  },
  {
    role: 'ai',
    evaluation: {
      score: 4.5,
      summary: '回答得非常出色!核心概念都掌握了。',
      pros: [
        '清楚地分點說明了三種主要情境。',
        '提到了與 Class Component 生命週期的類比,有助於理解。',
      ],
      cons: [
        '可以補充說明省略依賴陣列可能導致的無限循環風險。',
        '若能提到 `return` 一個清理函式 (cleanup function) 會更完整。',
      ],
    },
  },
  {
    role: 'ai',
    content: `非常好!觀念很清晰。那我們接著進行下一題程式實作題:**${mockInterviewSession.currentQuestion.title}**。請在右側的編輯器中完成它。`,
  },
];

const mockPracticeHistory = [
  {
    id: 1,
    title: 'React Hooks 深度剖析',
    type: '程式實作',
    date: '2025-09-14',
    score: 88,
    duration: '45 分鐘',
  },
  {
    id: 2,
    title: 'JavaScript 核心觀念',
    type: '概念問答',
    date: '2025-09-12',
    score: 92,
    duration: '25 分鐘',
  },
  {
    id: 3,
    title: 'CSS Flexbox & Grid',
    type: '程式實作',
    date: '2025-09-10',
    score: 75,
    duration: '55 分鐘',
  },
  {
    id: 4,
    title: '非同步 JavaScript (Async/Await)',
    type: '概念問答',
    date: '2025-09-09',
    score: 82,
    duration: '30 分鐘',
  },
];

const mockPracticeDetail = {
  ...mockPracticeHistory[0],
  submittedCode: mockInterviewSession.currentQuestion.templateCode,
  testResults: [
    { id: 1, description: '500ms 後應回傳最新值', passed: true },
    { id: 2, description: '值快速變化時不應觸發更新', passed: true },
    { id: 3, description: 'delay 參數應能動態改變', passed: true },
    { id: 4, description: '應處理初始值', passed: true },
  ],
  chatHistory: mockChatHistory,
};

export {
  mockDashboardStats,
  mockTopicProgress,
  mockConceptualTopics,
  mockCodingTopics,
  mockInterviewSession,
  mockChatHistory,
  mockPracticeHistory,
  mockPracticeDetail,
};

接著就可以把以下程式碼貼到下方app/(main)/dashboard/page.tsx的頁面中了:

'use client';
import {
  BarChart2,
  History,
  Zap,
  Target,
  MessageSquare,
  Code,
} from 'lucide-react';
import {
  mockDashboardStats,
  mockTopicProgress,
  mockPracticeHistory,
} from '@/app/lib/mockData';
import Link from 'next/link';

export default function DashboardPage() {
  return (
    <div className="p-8">
      <h2 className="text-3xl font-bold mb-2">歡迎回來,Danny!</h2>
      <p className="text-gray-400 mb-8">
        今天也是精進自己,成為頂尖工程師的一天!
      </p>

      {/* 統計數據 */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
        <div className="bg-gray-800 p-6 rounded-lg flex items-center gap-4">
          <BarChart2 className="text-green-400" size={32} />
          <div>
            <p className="text-3xl font-bold">
              {mockDashboardStats.averageScore}%
            </p>
            <p className="text-sm text-gray-400">平均得分</p>
          </div>
        </div>
        <div className="bg-gray-800 p-6 rounded-lg flex items-center gap-4">
          <History className="text-blue-400" size={32} />
          <div>
            <p className="text-3xl font-bold">
              {mockDashboardStats.totalSessions}
            </p>
            <p className="text-sm text-gray-400">總練習次數</p>
          </div>
        </div>
        <div className="bg-gray-800 p-6 rounded-lg flex items-center gap-4">
          <Zap className="text-yellow-400" size={32} />
          <div>
            <p className="text-3xl font-bold">{mockDashboardStats.streak} 天</p>
            <p className="text-sm text-gray-400">連續練習</p>
          </div>
        </div>
      </div>

      <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
        <div className="lg:col-span-2 space-y-8">
          {/* 開始練習 */}
          <div>
            <h3 className="text-xl font-bold mb-4">開始新的練習</h3>
            <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
              <Link
                href="/practice/conceptual"
                className="bg-gray-800 p-6 rounded-lg text-left hover:ring-2 ring-blue-500 transition"
              >
                <MessageSquare className="text-blue-400 mb-3" size={28} />{' '}
                <h3 className="text-lg font-semibold mb-1">概念問答</h3>{' '}
                <p className="text-sm text-gray-400">
                  測試你對前端技術的理解深度
                </p>
              </Link>
              <Link
                href="/practice/coding"
                className="bg-gray-800 p-6 rounded-lg text-left hover:ring-2 ring-purple-500 transition"
              >
                <Code className="text-purple-400 mb-3" size={28} />{' '}
                <h3 className="text-lg font-semibold mb-1">程式實作</h3>{' '}
                <p className="text-sm text-gray-400">實際撰寫程式碼解決問題</p>
              </Link>
            </div>
          </div>
          {/* 最近練習 */}
          <div>
            <h3 className="text-xl font-bold mb-4">最近的練習</h3>
            <div className="space-y-4">
              {mockPracticeHistory.slice(0, 3).map((item) => (
                <div
                  key={item.id}
                  className="bg-gray-800 p-4 rounded-lg flex items-center justify-between"
                >
                  <div className="flex items-center gap-4">
                    {item.type === '程式實作' ? (
                      <Code className="text-purple-400" />
                    ) : (
                      <MessageSquare className="text-blue-400" />
                    )}
                    <div>
                      <h4 className="font-semibold">{item.title}</h4>
                      <p className="text-sm text-gray-400">{item.date}</p>
                    </div>
                  </div>
                  <div className="text-right">
                    <p className="font-bold text-lg">{item.score}</p>
                    <p className="text-sm text-gray-400">得分</p>
                  </div>
                </div>
              ))}
            </div>
          </div>
        </div>
        {/* 學習進度 */}
        <div className="bg-gray-800 p-6 rounded-lg">
          <h3 className="text-xl font-bold mb-6 flex items-center gap-2">
            <Target size={22} /> 學習進度
          </h3>
          <div className="space-y-5">
            {mockTopicProgress.map((item) => (
              <div key={item.topic}>
                <div className="flex justify-between items-end mb-1">
                  <h4 className={`font-semibold text-sm ${item.color}`}>
                    {item.topic}
                  </h4>
                  <p className="text-sm font-mono text-gray-300">
                    {item.progress}%
                  </p>
                </div>
                <div className="w-full bg-gray-600 rounded-full h-2">
                  <div
                    className={`bg-gradient-to-r from-gray-400 to-current ${item.color} h-2 rounded-full`}
                    style={{ width: `${item.progress}%` }}
                  ></div>
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

完成後的主控台會像是這樣,讓 AI 參考了目前比較主流的學習頁面提出的設計,不算太新鮮的東西,但比一開始好多了!

圖1
圖1 :主控台頁面

Step 4: 實作測驗頁面 (Practice)

練習頁面會稍微多一點邏輯,我們會先根據使用者選擇的練習模式(concept | code) 去列出可以選擇的主題,接著再顯示對應的練習頁面。

app/(main)/practice/[type]page.tsx

'use client';
import { mockConceptualTopics, mockCodingTopics } from '@/app/lib/mockData';
import Link from 'next/link';
import { useParams } from 'next/navigation';

export default function TopicSelectionPage() {
  const params = useParams();
  const type = params.type as 'concept' | 'code';

  const isConceptual = type === 'concept';
  const title = isConceptual ? '概念問答' : '程式實作';
  const topics = isConceptual ? mockConceptualTopics : mockCodingTopics;

  return (
    <div className="p-8">
      <h2 className="text-3xl font-bold mb-2">{title}</h2>
      <p className="text-gray-400 mb-8">選擇一個主題開始你的練習</p>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {topics.map((topic) => (
          <div
            key={topic.id}
            className={`p-6 rounded-lg flex flex-col ${topic.bgColor} border border-gray-700`}
          >
            <h3 className={`text-xl font-bold mb-2 ${topic.color}`}>
              {topic.title}
            </h3>
            <p className="text-gray-400 text-sm mb-4 flex-1">
              {topic.description}
            </p>
            <div className="mb-4">
              <div className="flex justify-between items-end mb-1">
                <h4 className={`font-semibold text-xs text-gray-300`}>
                  掌握度
                </h4>
                <p className="text-sm font-mono text-gray-300">
                  {topic.progress}%
                </p>
              </div>
              <div className="w-full bg-gray-600/50 rounded-full h-2">
                <div
                  className={`${topic.color.replace(
                    'text',
                    'bg'
                  )} h-2 rounded-full`}
                  style={{ width: `${topic.progress}%` }}
                ></div>
              </div>
            </div>
            <Link
              href={`/interview/${topic.id}`}
              className={`w-full text-center mt-auto bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-lg transition-colors`}
            >
              開始練習
            </Link>
          </div>
        ))}
      </div>
    </div>
  );
}

圖2
圖2 :主題選擇頁面

再來就是點選開始練習後的頁面了,這部分我們回頭再過來處理,先繼續完成其他的頁面跟組件。

Step 5: 實作練習紀錄頁面 (History)

接著我們來完成練習紀錄,包含列表頁和詳情頁,不過在那之前,我們要先補上兩個共用元件的程式碼,請你先照我們之前的規劃新增components/interview/UserMessage.tsx檔案並寫入以下內容。

import { User } from 'lucide-react';

export default function UserMessage({ content }: { content: string }) {
  return (
    <div className="flex items-start gap-4 flex-row-reverse">
      <div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-500 flex items-center justify-center border border-gray-400">
        <User size={20} />
      </div>
      <p className="bg-blue-600 rounded-lg p-3">{content}</p>
    </div>
  );
}

另外就是 AI 訊息的部分,一樣請你新增components/interview/AIMessage.tsx組件並寫入以下內容。

import { Bot, Star, ThumbsUp, ThumbsDown } from 'lucide-react';

interface Evaluation {
  score: number;
  summary: string;
  pros: string[];
  cons: string[];
}

interface AiMessageProps {
  message: {
    content?: string;
    evaluation?: Evaluation;
  };
}

export default function AiMessage({ message }: AiMessageProps) {
  return (
    <div className="flex items-start gap-4">
      <div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center border border-blue-400">
        <Bot size={20} />
      </div>
      <div className="flex-1">
        {message.content && (
          <div
            className="bg-gray-700/80 rounded-lg p-3 text-gray-200"
            dangerouslySetInnerHTML={{
              __html: message.content.replace(
                /\*\*(.*?)\*\*/g,
                '<strong>$1</strong>'
              ),
            }}
          />
        )}
        {message.evaluation && (
          <div className="bg-gray-700/50 rounded-lg p-4 mt-2 border border-gray-600">
            <h3 className="font-bold mb-3 text-lg text-green-400">
              AI 綜合評分
            </h3>
            <div className="flex items-center gap-2 mb-3">
              {[...Array(5)].map((_, i) => (
                <Star
                  key={i}
                  size={20}
                  className={
                    i < Math.round(message.evaluation?.score || 0)
                      ? 'text-yellow-400 fill-yellow-400'
                      : 'text-gray-500'
                  }
                />
              ))}
              <span className="font-bold text-xl ml-2">
                {message.evaluation?.score || 0} / 5.0
              </span>
            </div>
            <p className="mb-4 italic">
              &quot;{message.evaluation?.summary || ''}&quot;
            </p>
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              <div>
                <h4 className="flex items-center gap-2 font-semibold text-green-300 mb-2">
                  <ThumbsUp size={16} /> 優點分析
                </h4>
                <ul className="list-disc list-inside space-y-1 text-sm">
                  {message.evaluation.pros.map((pro, i) => (
                    <li key={i}>{pro}</li>
                  ))}
                </ul>
              </div>
              <div>
                <h4 className="flex items-center gap-2 font-semibold text-orange-300 mb-2">
                  <ThumbsDown size={16} /> 改進建議
                </h4>
                <ul className="list-disc list-inside space-y-1 text-sm">
                  {message.evaluation.cons.map((con, i) => (
                    <li key={i}>{con}</li>
                  ))}
                </ul>
              </div>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

這些都弄好後就可以去處理列表的頁面囉!

首先是列表頁面:
app/(main)/history/page.tsx

'use client';
import { mockPracticeHistory } from '@/app/lib/mockData';
import { Code, MessageSquare, Clock, BarChart2 } from 'lucide-react';
import Link from 'next/link';

export default function PracticeHistoryPage() {
  return (
    <div className="p-8">
      <h2 className="text-3xl font-bold mb-6">練習紀錄</h2>
      <div className="space-y-4">
        {mockPracticeHistory.map((item) => (
          <Link
            href={`/history/${item.id}`}
            key={item.id}
            className="bg-gray-800 p-4 rounded-lg flex items-center justify-between hover:bg-gray-700/50 transition cursor-pointer"
          >
            <div className="flex items-center gap-4">
              {item.type === '程式實作' ? (
                <Code className="text-purple-400" />
              ) : (
                <MessageSquare className="text-blue-400" />
              )}
              <div>
                <h3 className="font-semibold">{item.title}</h3>
                <p className="text-sm text-gray-400">{item.date}</p>
              </div>
            </div>
            <div className="flex items-center gap-6">
              <div className="flex items-center gap-2 text-sm text-gray-300">
                <Clock size={16} /> {item.duration}
              </div>
              <div className="flex items-center gap-2 text-sm">
                <BarChart2 size={16} /> 評分:{' '}
                <span className="font-bold text-lg ml-1">{item.score}</span>
              </div>
              <span className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-lg transition-colors text-sm">
                查看詳情
              </span>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}
圖3
圖3 :練習紀錄列表頁面

再來是練習詳情頁面

app/(main)/history/[recordId]/page.tsx

import { mockPracticeDetail } from '@/app/lib/mockData';
import {
  ArrowLeft,
  Clock,
  BarChart2,
  CheckCircle,
  XCircle,
} from 'lucide-react';
import Link from 'next/link';
import AiMessage from '@/app/components/interview/AIMessage';

export default function RecordDetailPage({
  params,
}: {
  params: { recordId: string };
}) {
  // 實際上,你會用 params.recordId 去 fetch 資料
  const detail = mockPracticeDetail;

  return (
    <div className="p-8 max-w-5xl mx-auto">
      <Link
        href="/history"
        className="flex items-center gap-2 text-sm text-gray-400 hover:text-white mb-6"
      >
        <ArrowLeft size={16} /> 返回練習紀錄
      </Link>
      <div className="bg-gray-800 rounded-xl border border-gray-700 p-6 mb-8">
        <h2 className="text-2xl font-bold">{detail.title}</h2>
        <div className="flex items-center gap-6 text-sm text-gray-400 mt-2">
          <span>{detail.date}</span>
          <span className="flex items-center gap-1.5">
            <Clock size={14} />
            {detail.duration}
          </span>
          <span className="flex items-center gap-1.5">
            <BarChart2 size={14} />
            最終評分:{' '}
            <strong className="text-lg text-white ml-1">{detail.score}</strong>
          </span>
        </div>
      </div>
      <div className="space-y-8">
        <div>
          <h3 className="text-xl font-bold mb-4">你提交的程式碼</h3>
          <div className="bg-[#0d1117] rounded-xl border border-gray-700">
            <div className="p-2 border-b border-gray-700 bg-gray-900/50">
              <span className="text-sm text-gray-400">useDebounce.js</span>
            </div>
            <div className="p-4 overflow-auto">
              <pre className="text-sm">
                <code>{detail.submittedCode}</code>
              </pre>
            </div>
          </div>
        </div>
        <div>
          <h3 className="text-xl font-bold mb-4">測試案例結果</h3>
          <div className="bg-gray-800 rounded-xl border border-gray-700 p-4 space-y-3">
            {detail.testResults.map((result) => (
              <div key={result.id} className="flex items-center gap-3">
                {result.passed ? (
                  <CheckCircle
                    size={20}
                    className="text-green-500 flex-shrink-0"
                  />
                ) : (
                  <XCircle size={20} className="text-red-500 flex-shrink-0" />
                )}
                <span className="text-sm">{result.description}</span>
              </div>
            ))}
          </div>
        </div>
        {detail.aiFeedback && (
          <div>
            <h3 className="text-xl font-bold mb-4">AI 回饋重點</h3>
            <AiMessage message={{ evaluation: detail.aiFeedback }} />
          </div>
        )}
      </div>
    </div>
  );
}

圖4
圖4 :練習紀錄詳情頁面

Step 6: 實作面試核心頁面 (Interview)

終於,我們回頭做最重要的頁面,也就是兩個練習模式的介面,在開始之前我們需要先建立幾個型別,請你在app/types/interview.ts檔案中增加以下內容。

export interface Evaluation {
  score: number;
  summary: string;
  pros: string[];
  cons: string[];
}

export interface ChatMessage {
  role: 'user' | 'ai';
  content: string;
  evaluation?: Evaluation;
}

接著就可以回到我們的面試組件了,先從比較簡單的下手,components/interview/ConceptualInterview.tsx開始吧,請將下方的程式碼填入檔案中。

import AIMessage from './AIMessage';
import UserMessage from './UserMessage';
import { ChevronRight } from 'lucide-react';
import { ChatMessage } from '@/app/types/interview';
interface ConceptualInterviewProps {
  chatHistory: ChatMessage[];
  sessionInfo: { title: string };
  inputValue: string;
  onInputChange: (value: string) => void;
  onSubmit: () => void;
  isLoading: boolean;
}

export default function ConceptualInterview({
  chatHistory,
  sessionInfo,
  inputValue,
  onInputChange,
  onSubmit,
  isLoading,
}: ConceptualInterviewProps) {
  return (
    <div className="flex flex-col h-full p-4">
      <div className="max-w-3xl mx-auto w-full flex flex-col h-full bg-gray-800/50 rounded-xl border border-gray-700 overflow-hidden">
        <header className="p-4 border-b border-gray-700 flex-shrink-0">
          <h1 className="text-xl font-bold">{sessionInfo.title}</h1>
          <p className="text-sm text-gray-400">概念問答模式</p>
        </header>
        <div className="flex-1 p-4 space-y-6 overflow-y-auto">
          {chatHistory.map((msg, index) =>
            msg.role === 'ai' ? (
              <AIMessage key={index} message={msg} />
            ) : (
              <UserMessage key={index} content={msg.content} />
            )
          )}
        </div>
        <footer className="p-4 border-t border-gray-700 flex-shrink-0">
          <div className="relative">
            <textarea
              value={inputValue}
              onChange={(e) => onInputChange(e.target.value)}
              placeholder="在這裡輸入你的答案..."
              className="w-full h-24 bg-gray-700 rounded-lg p-3 pl-4 pr-12 text-white border border-gray-600 focus:ring-2 focus:ring-blue-500 outline-none resize-none"
              disabled={isLoading}
              onKeyDown={(e) => {
                if (e.key === 'Enter' && !e.shiftKey) {
                  e.preventDefault();
                  onSubmit();
                }
              }}
            />
            <button
              onClick={onSubmit}
              disabled={isLoading}
              className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 hover:text-blue-300 disabled:text-gray-500"
            >
              <ChevronRight size={24} />
            </button>
          </div>
        </footer>
      </div>
    </div>
  );
}
圖5
圖5 :概念練習頁面

接著要處理components/interview/CodingInterview.tsx的內容。

import Editor from '@monaco-editor/react';
import AIMessage from './AIMessage';
import UserMessage from './UserMessage';
import { FileText, Bot, Terminal } from 'lucide-react';
import { ChatMessage } from '@/app/types/interview';
interface CodingInterviewProps {
  sessionInfo: {
    currentQuestion: { title: string; description: string };
  };
  code: string;
  onCodeChange: (value: string) => void;
  chatHistory: ChatMessage[];
  onSubmit: () => void;
  isLoading: boolean;
}

export default function CodingInterview({
  sessionInfo,
  code,
  onCodeChange,
  chatHistory,
  onSubmit,
  isLoading,
}: CodingInterviewProps) {
  return (
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 h-full">
      {/* Left side: Question and Editor */}
      <div className="flex flex-col gap-4 h-full">
        <div className="bg-gray-800/50 rounded-xl border border-gray-700 p-4 flex-shrink-0">
          <h2 className="flex items-center gap-2 text-lg font-semibold text-blue-300 mb-2">
            <FileText size={20} />
            {sessionInfo.currentQuestion.title}
          </h2>
          <p className="text-gray-400 text-sm">
            {sessionInfo.currentQuestion.description}
          </p>
        </div>
        <div className="flex-1 flex flex-col bg-[#0d1117] rounded-xl border border-gray-700 overflow-hidden">
          <div className="flex-shrink-0 p-2 border-b border-gray-700 bg-gray-900/50">
            <span className="text-sm text-gray-400">index.js</span>
          </div>
          <Editor
            height="100%"
            language="javascript"
            theme="vs-dark"
            value={code}
            onChange={(value) => onCodeChange(value || '')}
            options={{ fontSize: 16, minimap: { enabled: false } }}
          />
        </div>
      </div>

      {/* Right side: AI Feedback and Controls */}
      <div className="flex flex-col bg-gray-800/50 rounded-xl border border-gray-700 overflow-hidden h-full">
        <header className="p-4 border-b border-gray-700 flex-shrink-0">
          <h1 className="text-lg font-bold flex items-center gap-2">
            <Bot size={20} /> AI 回饋
          </h1>
        </header>
        <div className="flex-1 p-4 space-y-6 overflow-y-auto">
          {chatHistory.map((msg, index) =>
            msg.role === 'ai' ? (
              <AIMessage key={index} message={msg} />
            ) : (
              <UserMessage key={index} content={msg.content} />
            )
          )}
        </div>
        <footer className="p-4 border-t border-gray-700 flex-shrink-0">
          <div className="flex justify-end items-center gap-3">
            <button
              className="flex items-center gap-2 bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-lg transition-colors text-sm"
              disabled={isLoading}
            >
              <Terminal size={16} /> 測試
            </button>
            <button
              onClick={onSubmit}
              className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg transition-colors text-sm"
              disabled={isLoading}
            >
              {isLoading ? '思考中...' : '提交答案'}
            </button>
          </div>
        </footer>
      </div>
    </div>
  );
}

圖6
圖6 :程式練習頁面

這兩個組件都完成後就可以整合進 Interview 頁面了,請把app/(main)/inerview/[sessionId]page.tsx的檔案內容寫入以下的程式碼:

'use client';

import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { Question } from '@/app/types/question';
import CodingInterview from '@/app/components/interview/CodingInterview';
import ConceptualInterview from '@/app/components/interview/ConceptualInterview';
import { ChatMessage } from '@/app/types/interview';
export default function InterviewPage() {
  const params = useParams();
  const sessionId = params.sessionId as string;

  const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
  const [isFetchingQuestion, setIsFetchingQuestion] = useState(true);
  const [answer, setAnswer] = useState('');
  const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchQuestion = async () => {
      try {
        setIsFetchingQuestion(true);
        const response = await fetch('/api/questions');
        const data: Question = await response.json();
        setCurrentQuestion(data);

        setChatHistory([
          { role: 'ai', content: '你好!我是你的 AI 前端面試官。' },
          { role: 'ai', content: `第一題:**${data.question}**` },
        ]);

        if (data.type === 'code' && data.starterCode) {
          setAnswer(data.starterCode);
        } else {
          setAnswer('');
        }
      } catch (error) {
        console.error('無法抓取題目:', error);
        setChatHistory([{ role: 'ai', content: '抱歉,載入題目時發生錯誤。' }]);
      } finally {
        setIsFetchingQuestion(false);
      }
    };
    fetchQuestion();
  }, [sessionId]);

  const handleSubmit = async () => {
    if (!answer || !currentQuestion) return;

    const newHistory: ChatMessage[] = [
      ...chatHistory,
      { role: 'user', content: answer },
    ];
    setChatHistory(newHistory);
    const currentAnswer = answer;
    setAnswer('');
    setIsLoading(true);

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          question: currentQuestion.question,
          answer: currentAnswer,
          keyPoints: currentQuestion.keyPoints,
        }),
      });

      if (!response.ok) throw new Error('API request failed');

      const data = await response.json();
      const aiResponse: ChatMessage = { role: 'ai', content: data.result };
      setChatHistory([...newHistory, aiResponse]);
    } catch (error) {
      console.error('錯誤:', error);
      const errorResponse: ChatMessage = {
        role: 'ai',
        content: '抱歉,我現在無法提供回饋,請稍後再試。',
      };
      setChatHistory([...newHistory, errorResponse]);
    } finally {
      setIsLoading(false);
    }
  };

  if (isFetchingQuestion) {
    return <div className="p-8 text-center text-gray-400">正在載入面試...</div>;
  }

  if (!currentQuestion) {
    return (
      <div className="p-8 text-center text-red-400">
        無法載入題目,請稍後再試。
      </div>
    );
  }

  return (
    <div className="h-full">
      {currentQuestion.type === 'code' ? (
        <CodingInterview
          sessionInfo={{
            currentQuestion: {
              title: currentQuestion.question,
              description: '',
            },
          }}
          code={answer}
          onCodeChange={setAnswer}
          chatHistory={chatHistory}
          onSubmit={handleSubmit}
          isLoading={isLoading}
        />
      ) : (
        <ConceptualInterview
          sessionInfo={{ title: currentQuestion.topic }}
          chatHistory={chatHistory}
          inputValue={answer}
          onInputChange={setAnswer}
          onSubmit={handleSubmit}
          isLoading={isLoading}
        />
      )}
    </div>
  );
}

補充說明:到這一步你可能會發現你明明點選了程式實作的主題開啟練習,但卻是顯示概念回答的問題與介面,那是因為我們目前 API 的邏輯還停留在隨機抽取題庫中的一題,還沒有把主題選擇的邏輯加進去,因此目前這會是個可接受的正常行為。

Step 7: 將專案部署到 Vercel

終於把所有的改動都先套上去了,馬上就開始我們的部署流程吧!

  1. 將你的專案推送到 Github,我想這應該不用我教了。
  2. 使用你的Github/ Google帳號登入 Vercel 平台。
  3. 在 Dashboard 畫面點選新增專案,你應該會看到以下的畫面:
    | 圖7
    |:--:|
    | 圖7 :Vercel專案選擇畫面 |

若你沒有看到你的專案在選擇清單中,請按照 Vercel 的教學給予它讀取你 Github 專案的權限,再次重新整理後就能看到你的專案了。
4. 設定環境變數,找到 "Environment Variables" 區塊,新增 GEMINI_API_KEY,把我們在 Day 2申請到的 Key填進去就行了。

圖8
圖8 :Vercel專案部署畫面
  1. 最後按下 Delpoy !就大功告成啦! 這幾年 Vercel 在部署方面做了很多的簡化,熱門的框架都能自動判斷要怎麼做設置,基本上你不太需要做額外的設置就能讓你的專案上雲了!

部署完成後點擊產生的網址,你就會看到我們之前那個醜頁面!但當你輸入/dashboard就可以看到我們努力一天的成果囉,一前一後的對比是不是覺得今天也推進太多進度了!

今日回顧與下週預告

太棒了!我們成功在第一週的尾聲,將一個完整的、多頁面的應用程式骨架搭建起來並成功部署。雖然裡面大部分還是假資料,但整體的流程和樣貌已經非常清晰。

下週一(Day 8),我們要開始為這個骨架注入靈魂。我們將深入探討專案的核心技術:RAG (檢索增強生成) 與 Embedding,在過程中我們會順帶講一下今天建立的一些組件的邏輯,配合一些動態的練習去熟悉這新的架構吧!

辛苦了,好好享受這個週末!🚀

今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-7


上一篇
讓 AI 更聰明:Prompt Engineering 初探
下一篇
AI 的開卷考試:初探 RAG 與 Embedding
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言